Skip to content

feat: ghost message bubbles, PromptInput focus, ChatPanel motion - #868

Merged
rohanchkrabrty merged 4 commits into
mainfrom
prompt-input-improvements
Jul 30, 2026
Merged

feat: ghost message bubbles, PromptInput focus, ChatPanel motion#868
rohanchkrabrty merged 4 commits into
mainfrom
prompt-input-improvements

Conversation

@rohanchkrabrty

Copy link
Copy Markdown
Contributor

Summary

  • Add variant="ghost" to Message.Bubble — no background, border or padding, spanning the full message row. It composes textVariants({ size: 'small' }) so it renders as literal Text output rather than a lookalike, and the grid widens via :has() (content column fit-content(85%)minmax(0, 1fr), spacer dropped) so hover actions land at the row's far edge. The Chat and ChatPanel examples now use it in place of the bare Text they were reaching for.
  • Clicking the PromptInput frame's dead space focuses the textarea, with cursor: text to advertise it. Handled on mousedown with preventDefault() rather than on click, so focus never leaves the textarea and comes back — a round trip that would dismiss anything anchored to it. Interactive targets, secondary mouse buttons, the disabled state and a consumer onMouseDown that calls preventDefault() all opt out.
  • Add the Co-pilot sparkle from the Figma icons file as CoPilotIcon, and use it as the default glyph for the minimized ChatPanel bubble (was Radix's ChatBubbleIcon).
  • Animate ChatPanel mode changes: position flips between in-flow and fixed, which no property can tween, so each mode plays a transform + opacity entrance at --rs-duration-normal / --rs-ease-out. The docked slide runs inward rather than as a full-width drawer slide from off-edge — a transform still grows the nearest scroll container's scroll area, so sliding outward would flash a horizontal scrollbar in any layout that doesn't clip overflow. A data-mode-changed attribute keeps first paint static, the floating entrance stands down mid-drag/resize, and everything is gated on prefers-reduced-motion.
  • Wire @svgr/rollup into the vitest config as a pre plugin — reusing an icons/ asset inside a component needs it, or Vite resolves the .svg to a URL string and the named ReactComponent export is undefined in tests. Docs updated across Message, PromptInput and ChatPanel.

rohanchkrabrty and others added 3 commits July 28, 2026 15:42
A ghost bubble drops the surface entirely — no background, border, padding
or radius — and runs the full width of the message row instead of
shrink-wrapping, which is the conventional treatment for assistant replies.

It composes textVariants({ size: 'small' }) so it renders as literal Text
output rather than a lookalike; `color` maps onto Text's colours. The grid
widens via :has(), swapping the content column's fit-content(85%) for
minmax(0, 1fr) and dropping the flexible spacer so hover actions land at the
row's far edge.

The Chat and ChatPanel examples now use it in place of the bare Text they
were reaching for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The composer reads as one field, so clicking its dead space — the padding and
the gaps around the header and footer slots — now focuses the textarea, with
`cursor: text` to advertise it.

Handled on mousedown with preventDefault() rather than on click: focus never
leaves the textarea and come back, a round trip that would dismiss anything
anchored to it. Interactive targets, secondary mouse buttons, the disabled
state and a consumer onMouseDown that calls preventDefault() all opt out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add the Co-pilot sparkle from the Figma icons file as CoPilotIcon and use it
as the default glyph for the minimized ChatPanel bubble, replacing Radix's
ChatBubbleIcon. Reusing an icons/ asset inside a component needs @svgr/rollup
wired into the vitest config as a `pre` plugin, otherwise Vite resolves the
.svg to a URL string and the named ReactComponent export is undefined in
tests; the rollup build already had it.

Mode changes flip the panel between in-flow and position: fixed, which no
property can tween, so each mode now plays a transform + opacity entrance at
--rs-duration-normal / --rs-ease-out: docked settles onto its edge, floating
scales and lifts, the bubble pops in (retimed from fast to match). The docked
slide runs inward rather than as a full-width drawer slide from off-edge — a
transform still grows the nearest scroll container's scroll area, so sliding
outward would flash a horizontal scrollbar in any layout that doesn't clip
overflow.

A data-mode-changed attribute keeps first paint static, and the floating
entrance stands down while a drag or resize is in flight so it never fights
the live dnd-kit transform. Everything is gated on prefers-reduced-motion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
apsara Ready Ready Preview Jul 30, 2026 6:19am

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The Message component adds a ghost bubble variant with text-based styling and full-row layout support, plus demos, documentation, and tests. ChatPanel adds configurable minimal and morph mode transitions, a CoPilot fallback icon, and transition-focused tests and documentation. PromptInput adds generic input registration and mousedown focus handling for custom targets, with updated slot styling, documentation, and tests.

Sequence Diagram(s)

sequenceDiagram
  participant ChatPanel
  participant ChatPanelRoot
  participant ChatPanelFrame
  participant ChatPanelCSS
  ChatPanel->>ChatPanelRoot: pass mode and transition
  ChatPanelRoot->>ChatPanelRoot: compute modeFrom and morph geometry
  ChatPanelRoot->>ChatPanelFrame: pass transition and modeFrom
  ChatPanelFrame->>ChatPanelCSS: render transition data attributes
Loading
sequenceDiagram
  participant User
  participant PromptInputForm
  participant PromptInputRoot
  participant RegisteredInput
  User->>PromptInputForm: mousedown on frame dead space
  PromptInputForm->>PromptInputRoot: invoke focus handler
  PromptInputRoot->>RegisteredInput: focus registered inputRef
Loading

Suggested reviewers: rohilsurana, shreyag02

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: ghost bubbles, PromptInput focus, and ChatPanel motion.
Description check ✅ Passed The description is directly related to the PR and accurately covers the major changes in the diff.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/raystack/components/chat-panel/__tests__/chat-panel.test.tsx`:
- Around line 702-712: Update the “falls back to the co-pilot icon” test to
verify the rendered icon is specifically CoPilotIcon rather than only checking
generic SVG attributes. Use a stable CoPilot-specific marker or mock CoPilotIcon
and assert its rendering, while retaining the existing fallback scenario.

In `@packages/raystack/components/chat-panel/chat-panel.module.css`:
- Around line 208-215: Scope the chat-panel-trigger-in entrance animation in the
prefers-reduced-motion rule to `.root[data-mode-changed] .trigger`, while
keeping the existing interactive transition on `.trigger` unchanged.

In `@packages/raystack/components/prompt-input/prompt-input-root.tsx`:
- Around line 60-61: Update the INTERACTIVE_TARGET selector to match all
contenteditable elements, including empty and "plaintext-only" values, so their
native focus is preserved and they are not redirected to the textarea.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fce4fd05-638f-4379-a62f-851a2c3ed7eb

📥 Commits

Reviewing files that changed from the base of the PR and between dd983bb and a955a92.

⛔ Files ignored due to path filters (1)
  • packages/raystack/icons/assets/co-pilot.svg is excluded by !**/*.svg
📒 Files selected for processing (19)
  • apps/www/src/content/docs/ai-elements/chat-panel/demo.ts
  • apps/www/src/content/docs/ai-elements/chat-panel/index.mdx
  • apps/www/src/content/docs/ai-elements/chat/demo.ts
  • apps/www/src/content/docs/ai-elements/message/demo.ts
  • apps/www/src/content/docs/ai-elements/message/index.mdx
  • apps/www/src/content/docs/ai-elements/message/props.ts
  • apps/www/src/content/docs/ai-elements/prompt-input/index.mdx
  • packages/raystack/components/chat-panel/__tests__/chat-panel.test.tsx
  • packages/raystack/components/chat-panel/chat-panel-root.tsx
  • packages/raystack/components/chat-panel/chat-panel-trigger.tsx
  • packages/raystack/components/chat-panel/chat-panel.module.css
  • packages/raystack/components/message/__tests__/message.test.tsx
  • packages/raystack/components/message/message-bubble.tsx
  • packages/raystack/components/message/message.module.css
  • packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx
  • packages/raystack/components/prompt-input/prompt-input-root.tsx
  • packages/raystack/components/prompt-input/prompt-input.module.css
  • packages/raystack/icons/index.tsx
  • packages/raystack/vitest.config.mjs

Comment on lines +702 to +712
it('falls back to the co-pilot icon', () => {
render(<BasicChatPanel defaultMode='minimized' />);
const icon = screen.getByTestId('bubble').querySelector('svg');
expect(icon).not.toBeNull();
expect(icon).toHaveAttribute('aria-hidden', 'true');
// Drawn from currentColor so the bubble's own colour carries through.
expect(icon?.querySelector('path')).toHaveAttribute(
'fill',
'currentColor'
);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the fallback assertion CoPilot-specific.

These checks only prove that some currentColor SVG rendered; reverting to another similarly styled fallback icon would still pass. Assert a stable CoPilot-specific characteristic, or mock CoPilotIcon and assert that mock is rendered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/raystack/components/chat-panel/__tests__/chat-panel.test.tsx` around
lines 702 - 712, Update the “falls back to the co-pilot icon” test to verify the
rendered icon is specifically CoPilotIcon rather than only checking generic SVG
attributes. Use a stable CoPilot-specific marker or mock CoPilotIcon and assert
its rendering, while retaining the existing fallback scenario.

Comment on lines 208 to 215
@media (prefers-reduced-motion: no-preference) {
.trigger {
animation: chat-panel-trigger-in var(--rs-duration-fast) var(--rs-ease-out);
/* Same tempo as the docked/floating entrances above, so the three modes
read as one transition rather than three unrelated ones. */
animation: chat-panel-trigger-in var(--rs-duration-normal)
var(--rs-ease-out);
transition: var(--rs-transition-interactive);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Gate the minimized-bubble entrance on a mode change.

.trigger animates whenever it mounts, so an initially minimized panel still animates despite lacking data-mode-changed. Keep the transition on .trigger, but scope the entrance animation to .root[data-mode-changed] .trigger.

Proposed fix
 `@media` (prefers-reduced-motion: no-preference) {
   .trigger {
-    /* Same tempo as the docked/floating entrances above, so the three modes
-       read as one transition rather than three unrelated ones. */
-    animation: chat-panel-trigger-in var(--rs-duration-normal)
-      var(--rs-ease-out);
     transition: var(--rs-transition-interactive);
   }
 
+  .root[data-mode-changed] .trigger {
+    animation: chat-panel-trigger-in var(--rs-duration-normal)
+      var(--rs-ease-out);
+  }
+
   .trigger:active {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@media (prefers-reduced-motion: no-preference) {
.trigger {
animation: chat-panel-trigger-in var(--rs-duration-fast) var(--rs-ease-out);
/* Same tempo as the docked/floating entrances above, so the three modes
read as one transition rather than three unrelated ones. */
animation: chat-panel-trigger-in var(--rs-duration-normal)
var(--rs-ease-out);
transition: var(--rs-transition-interactive);
}
`@media` (prefers-reduced-motion: no-preference) {
.trigger {
transition: var(--rs-transition-interactive);
}
.root[data-mode-changed] .trigger {
animation: chat-panel-trigger-in var(--rs-duration-normal)
var(--rs-ease-out);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/raystack/components/chat-panel/chat-panel.module.css` around lines
208 - 215, Scope the chat-panel-trigger-in entrance animation in the
prefers-reduced-motion rule to `.root[data-mode-changed] .trigger`, while
keeping the existing interactive transition on `.trigger` unchanged.

Comment thread packages/raystack/components/prompt-input/prompt-input-root.tsx Outdated
@rohanchkrabrty
rohanchkrabrty merged commit 5e38ff6 into main Jul 30, 2026
4 of 5 checks passed
@rohanchkrabrty
rohanchkrabrty deleted the prompt-input-improvements branch July 30, 2026 06:20

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/raystack/components/chat-panel/chat-panel-root.tsx`:
- Around line 97-101: Update the useEffect that restores the settled mode to use
a nested requestAnimationFrame: schedule setSettledMode(mode) inside a second
rAF, while preserving cancellation for both pending frames in the cleanup. Keep
the existing [from, mode] dependencies and starting-mode behavior unchanged.

In `@packages/raystack/components/chat-panel/chat-panel.module.css`:
- Around line 93-105: Update the morph measurement logic in useMorph within
chat-panel-root.tsx to check matchMedia('(prefers-reduced-motion: reduce)') and
avoid writing inline translate/scale values when reduced motion is enabled.
Preserve the existing morph behavior for users who prefer no motion reduction.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5681af68-ef5c-476f-ac29-35dce8c5e061

📥 Commits

Reviewing files that changed from the base of the PR and between a955a92 and bfca66a.

📒 Files selected for processing (19)
  • apps/www/src/content/docs/ai-elements/chat-panel/demo.ts
  • apps/www/src/content/docs/ai-elements/chat-panel/index.mdx
  • apps/www/src/content/docs/ai-elements/chat-panel/props.ts
  • apps/www/src/content/docs/ai-elements/prompt-input/index.mdx
  • apps/www/src/content/docs/ai-elements/prompt-input/props.ts
  • packages/raystack/components/chat-panel/__tests__/chat-panel.test.tsx
  • packages/raystack/components/chat-panel/chat-panel-root.tsx
  • packages/raystack/components/chat-panel/chat-panel.module.css
  • packages/raystack/components/chat-panel/index.tsx
  • packages/raystack/components/message/__tests__/message.test.tsx
  • packages/raystack/components/message/message-bubble.tsx
  • packages/raystack/components/message/message.module.css
  • packages/raystack/components/prompt-input/__tests__/prompt-input.test.tsx
  • packages/raystack/components/prompt-input/prompt-input-context.tsx
  • packages/raystack/components/prompt-input/prompt-input-root.tsx
  • packages/raystack/components/prompt-input/prompt-input-textarea.tsx
  • packages/raystack/components/prompt-input/prompt-input.module.css
  • packages/raystack/index.tsx
  • packages/raystack/vitest.config.mjs
💤 Files with no reviewable changes (2)
  • packages/raystack/components/message/message.module.css
  • packages/raystack/vitest.config.mjs
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/www/src/content/docs/ai-elements/chat-panel/index.mdx
  • apps/www/src/content/docs/ai-elements/prompt-input/index.mdx

Comment on lines +97 to +101
useEffect(() => {
if (!from) return;
const frame = requestAnimationFrame(() => setSettledMode(mode));
return () => cancelAnimationFrame(frame);
}, [from, mode]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does a style change written in a layout effect and reverted in the next requestAnimationFrame callback still trigger a CSS transition?

💡 Result:

No, a style change that is modified and then immediately reverted within the same requestAnimationFrame (rAF) callback will not trigger a CSS transition [1][2]. This behavior occurs because browsers optimize the rendering pipeline by batching DOM and style changes [3][4]. When you modify an element's style property (e.g., changing a width or opacity) and then change it back to its original value before the browser proceeds to the next rendering steps, the browser's style calculation and layout engines treat the net result as no change at all [2]. For a CSS transition to initiate, the browser must detect a change in the computed style of a transitionable property between two consecutive frames or style-recalculation points [1]. Because the rAF callback executes before the next repaint, the browser does not perform a "style flush" or reflow between your two conflicting style assignments [3][5]. As a result, the browser only sees the final state (the reverted state), which matches the initial state, effectively nullifying the transition [2]. To trigger a transition, you must ensure the browser processes the "initial" state and the "destination" state in separate rendering cycles [6][1]. Common techniques to achieve this include: 1. Double requestAnimationFrame: Wrapping the target style update in a nested rAF ensures the update happens after the browser has had an opportunity to paint the initial state [5][1]. 2. Forced Synchronous Layout (Reflow): Accessing a geometric property (such as offsetHeight or calling getComputedStyle) forces the browser to calculate the current layout synchronously, creating a "flush" that allows the subsequent style change to be recognized as a new destination [7][1][2]. Example of the reliable approach: box.style.width = '100px'; // Initial state requestAnimationFrame( => { requestAnimationFrame( => { box.style.transition = 'width 1s'; box.style.width = '200px'; // Destination state }); });

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate file and related chat-panel files:"
git ls-files | rg 'packages/raystack/components/chat-panel|chat-panel-root\.tsx|chat-panel' | head -100

echo
echo "Inspect target file outline/size:"
wc -l packages/raystack/components/chat-panel/chat-panel-root.tsx
ast-grep outline packages/raystack/components/chat-panel/chat-panel-root.tsx --view compact || true

echo
echo "Relevant target file sections:"
sed -n '1,180p' packages/raystack/components/chat-panel/chat-panel-root.tsx | cat -n

echo
echo "Search for from/settled/data-mode-starting references:"
rg -n "from|settled|mode-starting|requestAnimationFrame|requestAnimationFrame\\(" packages/raystack/components/chat-panel packages/raystack -g '*.tsx' -g '*.ts' | head -200

Repository: raystack/apsara

Length of output: 28071


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant CSS transition/data-mode-starting references:"
rg -n "data-mode-starting|data-mode-from|transition|from \\[" packages/raystack/components/chat-panel/chat-panel.module.css packages/raystack -g '*.css' -g '*.scss' | head -200

echo
echo "chat-panel.module.css size and relevant sections:"
wc -l packages/raystack/components/chat-panel/chat-panel.module.css
sed -n '1,260p' packages/raystack/components/chat-panel/chat-panel.module.css | cat -n

echo
echo "ChatPanelRoot usage around setting mode/transition and rendered panel:"
sed -n '760,860p' packages/raystack/components/chat-panel/chat-panel-root.tsx | cat -n
rg -n "useMorph\\(|useModeChange\\(|data-mode-starting|modeTransition|ChatPanelPosition" packages/raystack/components/chat-panel/chat-panel-root.tsx

Repository: raystack/apsara

Length of output: 34713


Use a nested rAF before restoring the settled mode.

The layout effect writes the translate/scale from-values synchronously, but this requestAnimationFrame callback can still run before repaint is interrupted, so the browser can batch the starting-frame styles and their removal into one recalc. A double rAF (or a trusted forced reflow) makes the from-frame style commit before removing data-mode-starting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/raystack/components/chat-panel/chat-panel-root.tsx` around lines 97
- 101, Update the useEffect that restores the settled mode to use a nested
requestAnimationFrame: schedule setSettledMode(mode) inside a second rAF, while
preserving cancellation for both pending frames in the cleanup. Keep the
existing [from, mode] dependencies and starting-mode behavior unchanged.

Comment on lines +93 to +105
@media (prefers-reduced-motion: no-preference) {
.root {
transition:
scale var(--rs-duration-normal) var(--rs-ease-out),
translate var(--rs-duration-normal) var(--rs-ease-out),
opacity var(--rs-duration-normal) var(--rs-ease-out);
}

/* The from-values are jumped to, not tweened into — the tween is the frame
after this rule stops applying. */
.root[data-mode-starting] {
transition: none;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Morph from-values aren't reduced-motion aware.

All transitions live inside prefers-reduced-motion: no-preference, but useMorph writes inline translate/scale regardless. Under reduced motion the panel is displaced for one frame and then snaps back with no tween — the opposite of the intent. Gate the morph measurement on matchMedia('(prefers-reduced-motion: reduce)') in chat-panel-root.tsx.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/raystack/components/chat-panel/chat-panel.module.css` around lines
93 - 105, Update the morph measurement logic in useMorph within
chat-panel-root.tsx to check matchMedia('(prefers-reduced-motion: reduce)') and
avoid writing inline translate/scale values when reduced motion is enabled.
Preserve the existing morph behavior for users who prefer no motion reduction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants